package j2bugzilla.logging;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import org.apache.log4j.AppenderSkeleton;
import org.apache.log4j.Level;
import org.apache.log4j.spi.LoggingEvent;

import com.j2bugzilla.base.Bug;
import com.j2bugzilla.base.BugFactory;
import com.j2bugzilla.base.BugzillaConnector;
import com.j2bugzilla.base.BugzillaException;
import com.j2bugzilla.base.Comment;
import com.j2bugzilla.base.ConnectionException;
import com.j2bugzilla.rpc.BugSearch;
import com.j2bugzilla.rpc.BugSearch.SearchLimiter;
import com.j2bugzilla.rpc.CommentBug;
import com.j2bugzilla.rpc.LogIn;
import com.j2bugzilla.rpc.ReportBug;

/**
 * The {@code BugzillaAppender} class is a custom Log4J appender that allows log entries to be uploaded as bug reports
 * to an installation of Bugzilla. Only entries of {@code ERROR} or higher are reported, to keep your remote
 * installation from being flooded with meaningless entries.
 * 
 * The {@code BugzillaAppender} class exposes several configuration properties which must be specified in your
 * Log4J configuration. They are as follows:
 * 
 * <ul>
 * <li>Host</li>
 * <li>Product</li>
 * <li>Component</li>
 * <li>Version</li>
 * <li>OS</li>
 * <li>Platform</li>
 * </ul>
 * 
 * Additionally, if your Bugzilla installation requires authentication, you may include the {@code User} and
 * {@code Password} properties.
 * @author Tom
 *
 */
public class BugzillaAppender extends AppenderSkeleton {

	/**
	 * A {@link BugzillaConnector} constructed during {@link #activateOptions()}
	 */
	private BugzillaConnector conn;
	
	/**
	 * The remote Bugzilla installation to connect to. It is not required to include the {@code /xmlrpc.cgi} path.
	 */
	private String host;
	
	/**
	 * The username to authenticate as.
	 */
	private String user;
	
	/**
	 * The password to authenticate with.
	 */
	private String pass;
	
	/**
	 * The product for which this appender will report bugs under.
	 */
	private String product;
	
	/**
	 * The component for which this appender will report bugs under.
	 */
	private String component;
	
	/**
	 * The version for which this appender will report bugs under.
	 */
	private String version;
	
	/**
	 * The operating system for which this appender will report bugs under.
	 */
	private String os;
	
	/**
	 * The platform for which this appender will report bugs under.
	 */
	private String platform;

	/**
	 * Some custom content to place at the end of the description.
	 */
	private String descriptionAppendix;
	
	/**
	 * Private flag indicating whether or not we connected successfully in {@link #activateOptions()}.
	 */
	private boolean connSuccessful = false;
	
	/**
	 * An {@link ExecutorService} which allows us to dispatch bug reports over the network on a background thread.
	 */
	private ExecutorService workerService;
	
	public void setHost(String host) {
		this.host = host;
	}
	
	public String getHost() {
		return host;
	}
	
	public void setUser(String user) {
		this.user = user;
	}
	
	public String getUser() {
		return user;
	}
	
	public void setPass(String pass) {
		this.pass = pass;
	}
	
	public String getPass() {
		return pass;
	}
	
	public void setProduct(String product) {
		this.product = product;
	}
	
	public String getProduct() {
		return product;
	}
	
	public void setComponent(String component) {
		this.component = component;
	}
	
	public String getComponent() {
		return component;
	}
	
	public void setVersion(String version) {
		this.version = version;
	}
	
	public String getVersion() {
		return version;
	}
	
	public void setOperatingSystem(String os) {
		this.os = os;
	}
	
	public String getOperatingSystem() {
		return os;
	}
	
	public void setPlatform(String platform) {
		this.platform = platform;
	}
	
	public String getPlatform() {
		return platform;
	}

	public void setDescriptionAppendix(String descriptionAppendix) {
		this.descriptionAppendix = descriptionAppendix;
	}

	public String getDescriptionAppendix() {
		return this.descriptionAppendix;
	}
	
	/**
	 * Submits a new bug report for {@code ERROR} and {@code FATAL} log events, if a connection was successfully established.
	 * If a bug report for the given message and exception already exists, a comment will be appended instead.
	 */
	@Override
	protected void append(final LoggingEvent e) {
		if(!connSuccessful) { return; }
		
		Level level = e.getLevel();
		if(!level.equals(Level.ERROR) && !level.equals(Level.FATAL)) {
			return;
			//Ignore low-level logging events even if we have been configured to append them.
			//Otherwise, we risk making LOTS of network calls.
		}
		
		//Should we run network requests in another thread for append()?
		//Most of these calls are fairly long-running and should not slow down the rest of program execution.
		workerService.submit(new Runnable() {

			@Override
			public void run() {
				String renderedMessage = e.getRenderedMessage();//Use this for comments
				//Check if alias exists already or post new one
				String[] throwInfo = e.getThrowableStrRep();
				String alias = createAlias(throwInfo[1]);
				
				BugSearch checkExists = new BugSearch(SearchLimiter.ALIAS, alias);
				try {
					conn.executeMethod(checkExists);
					List<Bug> results = checkExists.getSearchResults();
					if(results.isEmpty()) {						
						String throwable = e.getThrowableInformation().getThrowable().toString();
						String date = new SimpleDateFormat("MM/dd/yyyy").format(new Date());
						
						String summary = throwable + " thrown on " + date;
						
						String description = createDescription(renderedMessage, e.getThrowableStrRep());
						
						Bug bug = new BugFactory().newBug()
								.setProduct(product)
								.setComponent(component)
								.setSummary(summary)
								.setVersion(version)
								.setDescription(description)
								.setOperatingSystem(os)
								.setPlatform(platform)
								.setAlias(alias)
								.createBug();
						
						ReportBug reportNew = new ReportBug(bug);
						conn.executeMethod(reportNew);
					} else {
						assert(results.size() == 1);//Aliases should be unique
						
						Bug bug = results.get(0);
						CommentBug addComment = new CommentBug(bug, new Comment(createDescription(renderedMessage, e.getThrowableStrRep())));
						conn.executeMethod(addComment);
					}
				} catch (BugzillaException ex) {
					errorHandler.error("Can't update Bugzilla installation.");
				}
			}
			
		});
		

	}

	/**
	 * Ensures all pending reports are flushed by the {@link #workerService}.
	 */
	@Override
	public void close() {
		workerService.shutdown();
	}

	/**
	 * {@inheritDoc}
	 */
	@Override
	public boolean requiresLayout() {
		return false;
	}
	
	/**
	 * Attempts to establish a connection to the specified host with the specified credentials. 
	 */
	@Override
	public void activateOptions() {
		workerService = Executors.newSingleThreadExecutor();
		
		conn = new BugzillaConnector();
		try {
			conn.connectTo(host);
			
			if(user != null && pass != null) {
				LogIn logIn = new LogIn(user, pass);
				conn.executeMethod(logIn);
			}
			connSuccessful = true;
		} catch (ConnectionException e) {
			errorHandler.error("Could not connect to specified host: " + host);
		} catch (BugzillaException e) {
			errorHandler.error("Could not log in with specified credentials: " + user + ", " + pass);
		}
	}
	
	/**
	 * Creates a unique(ish) alias for a given log entry based on the line from the included stack trace.
	 * @param sourceLine A line from the exception stack trace of the form 
	 * {@code "at <classname>(<source>:<line>)"}.
	 * @return A {@code String} of 20 characters or less to be used as a default Bugzilla alias.
	 */
	public String createAlias(String sourceLine) {
		int endOfLine = sourceLine.lastIndexOf(")");
		int beginningOfLine = sourceLine.lastIndexOf(":");
		String lineNum = sourceLine.substring(beginningOfLine+1, endOfLine);
		
		int beginningOfClass = sourceLine.indexOf("at ") + 3;
		int endOfClass = sourceLine.indexOf("(");
		String methodInvocation = sourceLine.substring(beginningOfClass, endOfClass);
		String className = methodInvocation.substring(0, methodInvocation.lastIndexOf("."));
		
		return createAlias(lineNum, className);
	}
	
	/**
	 * Creates a unique(ish) alias for a given log entry based on the class name and line number of the log call.
	 * @param lineNum The line number reported by the {@link LoggingEvent}.
	 * @param className The fully-qualified class name reported by the {@link LoggingEvent}.
	 * @return A String of 20 characters or less to be used as a default Bugzilla alias.
	 */
	public String createAlias(String lineNum, String className) {
		
		String[] pieces = className.split("\\.");
		String actualClass = pieces[pieces.length-1];
		
		//Bugzilla aliases can be 20 characters at most by default
		int maxLength = 20 - (lineNum.length() + 1);
		int classEndAt = (maxLength < actualClass.length()) ? maxLength  : actualClass.length();
		actualClass = actualClass.substring(0, classEndAt);
		return actualClass + "-" + lineNum;
	}
	
	/**
	 * Creates a descriptive message from the message passed by the log call and the stack trace of any attached
	 * {@link Throwable} to use as the description or comment of a bug report.
	 * @param renderedMessage The message rendered by the appender.
	 * @param stackTrace The stack trace included with the {@link LoggingEvent}.
	 * @return A descriptive {@code String}.
	 */
	public String createDescription(String renderedMessage, String[] stackTrace) {
		String desc = renderedMessage + "\n";
		
		for(String frame : stackTrace) {
			desc += (frame + "\n");
		}

		if (descriptionAppendix != null) {
			desc += "\n"+descriptionAppendix+"\n";
		}

		return desc;
	}
}
